package gptma.bo.dao.querier;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

import javax.persistence.EntityManager;
import javax.persistence.Query;

/**
 * Object that translates CriterionSet instances into JPA queries. Transaction &
 * EM management is not performed by this class. A valid & opened EM instance
 * must thus be given to the constructor.
 * 
 * Below is given an example of query that can be generated
 * 
 * select distinct(o) from equant.business.index.InvoiceIndex o,
 * in(o.dataExtractCollection) elem1, in(o.dataExtractCollection) elem2 left
 * join fetch o.relatedFileCollection inner join fetch o.pk.partition where (
 * 1=1 ) and o.pk.partition.partitionState in ( 1, 2, 8 ) and o.invoiceNumber
 * like '1970300020' and elem1.dataKey = 'CTR' and elem1.dataValue = '10118158'
 * and elem2.dataKey = 'PRJ' and elem2.dataValue = 'SMIS'
 * 
 * @author xcl
 * 
 * @param <T>
 *            searched entity type
 */
public class JpaQuerier<T> implements GenericQuerier<T> {

	/**
	 * EntityManager
	 */
	private EntityManager em;

	/**
	 * Entity class
	 */
	private Class<? extends T> clazz;

	/**
	 * Constructor
	 * 
	 * @param em
	 *            already opened entityManager
	 * @param clazz
	 *            entity class
	 */
	public JpaQuerier(EntityManager em, Class<? extends T> clazz) {
		this.em = em;
		this.clazz = clazz;
	}

	@Override
	@SuppressWarnings("unchecked")
	public List<T> findByCriteria(CriterionSet criterionSet) {
		Query q = generateQuery(criterionSet);
		return q.getResultList();
	}

	/**
	 * Generates JPQL query according to the given criterionSet
	 * 
	 * @param criterionSet
	 * @return
	 */
	private Query generateQuery(CriterionSet criterionSet) {
		List<SearchCriterion> items = criterionSet.getCriteria();
		List<CollectionElementCriterion> crits = criterionSet
				.getCollectionCriteria();
		Map<String, Boolean> relationships = criterionSet
				.getFetchedRelationships();
		// generate JPQL
		StringBuilder ql = new StringBuilder();

		ql.append(generateSelect(relationships, crits));

		ql.append("where (1=1)");

		// generate base criterion
		int namedParameterIndex = 1;
		for (SearchCriterion item : items) {
			ql.append(" and ").append(generateProperty(item, "o")).append(" ")
					.append(generateOperand(item)).append(" ")
					.append(generateNamedParameter(item, namedParameterIndex));

			namedParameterIndex++;
		}

		// generate collection criterion
		int collectionIndex = 1;
		for (CollectionElementCriterion collectionElementCriterion : crits) {
			// for each targetted collection, generate the list of search
			// criteria
			for (SearchCriterion item : collectionElementCriterion
					.getCriteria()) {
				ql.append(" and ")
						.append(generateProperty(item, "elem" + collectionIndex))
						.append(" ")
						.append(generateOperand(item))
						.append(" ")
						.append(generateNamedParameter(item,
								namedParameterIndex));

				namedParameterIndex++;
			}
			collectionIndex++;
		}

		// create the query
		Query q = em.createQuery(ql.toString());

		// set according base parameters
		namedParameterIndex = 1;
		for (SearchCriterion item : items) {
			q.setParameter(namedParameterIndex,
					generateNamedParameterValue(item));
			namedParameterIndex++;
		}

		// set according collection parameters
		for (CollectionElementCriterion collectionElementCriterion : crits) {
			for (SearchCriterion item : collectionElementCriterion
					.getCriteria()) {
				q.setParameter(namedParameterIndex,
						generateNamedParameterValue(item));
				namedParameterIndex++;
			}
		}

		return q;
	}

	/**
	 * Generates JPQL from instruction
	 * 
	 * @param relationship
	 *            fetched relationships
	 * @return JPQL fetch query
	 */
	private String generateSelect(Map<String, Boolean> relationships,
			List<CollectionElementCriterion> collectionCriteria) {
		StringBuilder ret = new StringBuilder();
		boolean empty = relationships.isEmpty();
		if (empty) {
			ret.append("select o from ");
		} else {
			// join will be used, so use distinct (cartesian product)
			ret.append("select distinct(o) from ");
		}

		ret.append(clazz.getName()).append(" o ");

		// generate JPQL collection "in" selector
		int index = 1;
		for (CollectionElementCriterion criterion : collectionCriteria) {
			ret.append(", in(o.").append(criterion.getCollectionName())
					.append(") elem").append(index).append(" ");
			index++;
		}

		// generate JPQL fetch orders
		for (Entry<String, Boolean> entry : relationships.entrySet()) {
			String relation = entry.getKey();
			if (entry.getValue()) {
				// mandatory fetch
				ret.append("inner join fetch o.");
			} else {
				// optional fetch
				ret.append("left join fetch o.");
			}
			ret.append(relation).append(" ");
		}

		return ret.toString();
	}

	/**
	 * Generates JPQL property, according to case-insensitivity
	 * 
	 * @param criterion
	 *            criterion
	 * @param targetAlias
	 *            alias given to the target object
	 * @return JPQL property
	 */
	private String generateProperty(SearchCriterion criterion,
			String targetAlias) {
		String ret = null;

		String property = criterion.getProperty();

		if (isCaseInsensitiveCriterion(criterion)) {
			// case-insensitive, use upper
			ret = new StringBuilder("upper(").append(targetAlias).append(".")
					.append(property).append(")").toString();
		} else {
			ret = new StringBuilder(targetAlias).append(".").append(property)
					.toString();
		}

		return ret;
	}

	/**
	 * Returns the JPQL operand, according to the provided searchMode
	 * 
	 * @param criterion
	 *            criterion
	 * @return JPQL operand
	 */
	private String generateOperand(SearchCriterion criterion) {
		String ret = null;

		SearchMode mode = criterion.getMode();

		switch (mode) {
		case STARTS_WITH:
		case ENDS_WITH:
		case CONTAINS:
			ret = "like";
			break;

		case IN:
			ret = "in";
			break;

		case NOT_EQUALS:
			ret = "";
			break;

		case LESS_THAN:
			ret = "<";
			break;

		case GREATER_THAN:
			ret = ">";
			break;

		case LESS_THAN_OR_EQUAL:
			ret = "<=";
			break;

		case GREATER_THAN_OR_EQUAL:
			ret = ">=";
			break;

		case EQUALS:
		default:
			Object value = criterion.getValue();

			if (value instanceof String) {
				// be able to use jokers, see generateNamedParameterValue
				ret = "like";
			} else {
				ret = "=";
			}
			break;
		}

		return ret;
	}

	/**
	 * Generates the JPQL named parameter, according to the provided searchMode
	 * 
	 * @param criterion
	 *            criterion
	 * @param namedParameterIndex
	 *            index of the parameter in the JPQL query (starts at 1)
	 * @return JPQL named parameter
	 */
	private String generateNamedParameter(SearchCriterion criterion,
			int namedParameterIndex) {
		String ret = null;

		SearchMode mode = criterion.getMode();

		switch (mode) {
		case IN:
			ret = new StringBuilder("(?").append(namedParameterIndex)
					.append(")").toString();
			break;

		default:
			ret = "?" + namedParameterIndex;
			break;
		}

		return ret;
	}

	/**
	 * Returns the JPQL named parameter value, according to the provided
	 * searchMode
	 * 
	 * @param criterion
	 *            criterion
	 * @return JPQL named parameter value
	 */
	private Object generateNamedParameterValue(SearchCriterion criterion) {
		Object ret = null;

		SearchMode mode = criterion.getMode();
		Object value = criterion.getValue();

		switch (mode) {
		case STARTS_WITH:
			ret = value + "%";
			break;

		case ENDS_WITH:
			ret = "%" + value;
			break;

		case CONTAINS:
			ret = new StringBuilder("%").append(value).append("%").toString();
			break;

		case EQUALS:
		default:
			ret = value;
			break;
		}

		// generate upper in Java, not in SQL
		if (isCaseInsensitiveCriterion(criterion)) {
			// we are sure to have a string
			ret = ret.toString().toUpperCase();
		}

		return ret;
	}

	/**
	 * Returns true if the given criterion is case insensitive: it is a string
	 * criterion and it has been set to case insensitive
	 * 
	 * @param criterion
	 *            criterion
	 * @return true if it is a case-insensitive criterion
	 */
	private boolean isCaseInsensitiveCriterion(SearchCriterion criterion) {
		SearchMode mode = criterion.getMode();
		Object value = criterion.getValue();

		List<SearchMode> stringModes = Arrays.asList(SearchMode.IN,
				SearchMode.EQUALS, SearchMode.STARTS_WITH,
				SearchMode.ENDS_WITH, SearchMode.CONTAINS,
				SearchMode.NOT_EQUALS);

		// do we have a string criterion?
		boolean stringMode = value instanceof String
				&& stringModes.contains(mode);

		return stringMode && !criterion.isCaseSensitive();
	}

}
